Skip to content

fix: version comparison fails closed instead of fabricating update notices - #155

Merged
ViperJuice merged 20 commits into
mainfrom
fix/version-compare-failclosed
Aug 17, 2026
Merged

ViperJuice merged 20 commits into
mainfrom
fix/version-compare-failclosed

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

Prerequisite for #150. Independently useful and independently testable, so it lands on its own.

The bug

is_version_newer extracted every digit from both strings and compared the results, with no way to signal "this is not a version". Returning True makes the gateway tell an operator their server is out of date, so an unreadable version could only ever produce a false claim. Reproduced on main:

is_version_newer('nightly',           '2.0.0') -> True
is_version_newer('release-channel-a', '2.0.0') -> True
is_version_newer('build-1',           '2.0.0') -> True
is_version_newer('',                  '2.0.0') -> True
is_version_newer('1.0.0',      'sha256:abc123') -> True

Each of those emits an "update available" notice that is not true.

The empty-string case is reachable in ordinary use, not a contrived input: MCPServer.__init__ in the current MCP SDK defaults version to "", and FastMCP 1.x reported the SDK version rather than the package's. Both were already documented in this repo (plans/phase-plan-v11-PG.md:44).

The fix

Recognise a release number specifically — 1.2.3, 1.0, 2025.12.18, a v prefix, and pre-release/build suffixes like -rc1 or +build.5 — and report no update when either side is not one.

Two subtleties worth calling out:

  • "Contains a digit" is not enough. build-1 parses to (1,) under a digits-anywhere scan and compares as older than every real release. The predicate has to anchor on a leading numeric release component, or this specific fabrication survives. My first attempt at this fix got it wrong and the behaviour matrix caught it.
  • Docker would otherwise go silent. get_docker_version returns a sha256: digest, which is version-shaped by no definition. Digests are now compared for inequality — a different digest genuinely is a new image, while ordering hex is meaningless — so the docker lane keeps working instead of being silenced by the stricter rule.

Existing documented behaviour is preserved, including 1.0.0-rc1 -> 1.0.0-rc2 = True and the test_non_numeric_versions contract that alpha/beta are mutually not-newer.

Verification

  • pytest -q -> 2541 passed, 3 skipped, 0 failed (2536 baseline + 5 new tests)
  • ruff check ., ruff format --check src/ tests/, mypy src — clean
  • All 69 pre-existing test_version_checker.py tests still pass, unmodified
  • RED proof: reverting only src/pmcp/manifest/version_checker.py fails 3 of the new tests, including is_version_newer('1.0.0', 'sha256:abc123') -> True

Why this is separate from #150

#150 needs a version source whose identity is bound to the package being compared — five attempts have failed at that, and the analysis is recorded across two plan artifacts and a closed PR (#154). But whatever source is chosen, it will fabricate notices while the comparator cannot fail. This makes the rest sound.

…ects)

Panel: grok-4.6 AGREE, codex DISAGREE, gemini DISAGREE. Two legs independently
found the same policy-gating contradiction; codex found two more. All three
verified against source:

B1 - the notice fix was half a fix: changing only the 'latest' lookup to the
configured package leaves 'current' as a version generated from the MANIFEST
package, so unrelated packages are still compared. GeneratedServerDescriptions
carries a  field the original plan never consulted, and the proposed
test (assert B was queried) would have passed while the bug survived.

B2 - update_server calls refresh() AFTER the probe, which reloads config.
Passing the stale probed config into the restart would disconnect the edited
server and reconnect the old one - worse than the TOCTOU being fixed. Replaced
with re-resolve-and-compare: abort with ok=False on drift, mutate no cache.

B3 - _load_all_configured_servers returns entries BEFORE policy filtering (its
own docstring), so a no-gating helper returns command/args for a denied server
and the policy-silence acceptance criterion was unsatisfiable. Added an
is_server_allowed check - a pure predicate, so it stays side-effect free.

Tests now assert observable outcomes (was a notice emitted, what did the cache
record) rather than argument plumbing.
Follows three board rounds that each rejected a patch-level fix in PR #154.
Root cause recorded: _stale_check_cache is keyed by server name alone while
carrying versions that belong to a specific package, and its three readers
(_run_stale_index, _get_update_warning, catalog_search) apply different
TTL/identity contracts -- so every fix patched one reader while another kept
serving the stale tuple. Plan re-keys the cache on (server, package) so a
cross-package entry is unrepresentable rather than something each reader must
remember to detect.
Two legs converged independently on the fatal flaw: re-keying _stale_check_cache
on (server, package) does not make a cross-package comparison unrepresentable,
because both writers still source 'current' from the server-scoped
descriptions_cache.version -- so an A->B switch stores (A_version, B_latest)
under key (server, B) and the comparison stays wrong behind an authoritative-
looking key.

grok named the root cause my framing missed: get_package_version returns
UPSTREAM LATEST only. Nothing in the system observes an INSTALLED version, and
descriptions_cache.version is itself an upstream snapshot from describe time.
That is why no attempt to 'establish a baseline' for the new package could ever
work -- the baseline does not exist. #150 is not a cache-keying bug; it needs a
decision about installed-version provenance first.
…150)

Maintainer decision after the re-keying plan was boarded 3/3 DISAGREE: probe
the installed version directly rather than continuing to infer it.

The probe already exists and pmcp discards it. _send_initialize reads
protocolVersion and capabilities out of the initialize result and ignores
serverInfo in the same dict. Verified against the installed mcp library:
InitializeResult.server_info is REQUIRED and carries Implementation.version.
Every conforming server reports its own version on every connect.

That is the installed-version source all four prior attempts lacked -- no new
subprocess, no new network call, no new failure mode, and it is authoritative
for what is actually RUNNING rather than what config claims. Notices become
running-version vs upstream-latest instead of upstream-snapshot vs upstream-now.

Carries forward unchanged: the .mcp.json precedence + is_server_allowed gate,
and the whole #151 drift fix. Deletes _repoint_cache_entry_to_effective_package,
the self-heal that caused permanent silence and erased good baselines.
…REE)

B1 (fatal): serverInfo.version is an IMPLEMENTATION version, not an installed
package version -- and this repo already documented it at
plans/phase-plan-v11-PG.md:44, which I failed to find before planning. FastMCP
1.x reports the mcp LIBRARY version; mcp 2.x defaults it to empty. My live
context7 check (serverInfo 4.0.2 == npm 4.0.2) showed the value CAN match, not
that it MUST -- I over-read one confirming example as an identity guarantee.

B3: 'unparseable means silence' is false. is_version_newer extracts digits and
never signals a parse failure -- 'nightly', 'build-1', '' and '1.29.0' all
compare as older than 2.0.0 and emit a notice. The two worst cases are exactly
B1's defaults. My proposed test used a string that happens to return False, so
it would have passed by accident while the criterion it claimed to prove was
false -- the fourth time a test of mine would have passed while its bug lived.

Direct probing remains right; serverInfo is the wrong probe. Any real fix needs
a version whose identity is bound to the package compared, plus a fail-closed
parse mode in is_version_newer.
@ViperJuice

Copy link
Copy Markdown
Collaborator Author

Board: 3/3 DISAGREE — not merging. All three findings reproduce.

I verified each against source and reproduced them at PR head. Every one holds, and the second is the same class of mistake I opened this PR to fix.

1. The fail-closed gate still accepts malformed versions

_VERSION_RE's suffix group (?:[-+.].*)? swallows everything after the first -, + or ., and _parse_version_parts then extracts every digit:

is_version_newer('2026-08-17-nightly', '3000.0.0') -> True    # parses as (2026, 8, 17)
is_version_newer('1.not-a-version',    '2.0.0')    -> True

2026-08-17-nightly is the string my own test declares "unreadable". It passed only because I compared it against 2.0.0, where (2026,8,17) > (2,0,0) happens to give False. Change the operand to 3000.0.0 and the fabrication reappears. That is the fourth — now fifth — time a test of mine has passed while its bug survived.

2. My docker handling silences docker entirely

This is the one that stings. get_docker_version strips the sha256: prefix and returns a bare 12-hex string (version_checker.py:248-252, pinned by the existing test at test_version_checker.py:697). My _is_digest only matches values that start with sha256:, so it never matches the real producer:

_is_digest('abcdef123456')                        -> False
is_version_newer('abcdef123456','fedcba654321')   -> False   # real digest change, SILENCED

I wrote that guard specifically to keep the docker lane working, tested it against sha256:-prefixed values I invented, and never checked what the producer actually returns. The result is exactly the failure I claimed to prevent — and the PR body asserts the opposite in as many words.

3. Accepted semver suffixes are ordered wrongly

Digit extraction ignores semver precedence:

is_version_newer('1.0.0-rc1', '1.0.0')          -> False   # hides a real stable release
is_version_newer('1.0.0',     '1.0.0-rc1')      -> True    # fabricates: a prerelease is NOT newer
is_version_newer('1.0.0+build.4','1.0.0+build.5') -> True  # build metadata is not precedence

This is pre-existing behaviour that my change preserved (the -rc1/-rc2 case is pinned by an existing test), but the PR claims prerelease support and a fail-closed contract, and it delivers neither.

Assessment

The right fix is almost certainly to stop hand-rolling this: parse with a real implementation (packaging.version for PEP 440, plus explicit semver precedence) and treat a parse failure as "no update". Hand-rolled digit extraction has now produced a fabricated-notice bug in every form it has taken.

Also needed regardless: _is_digest must match what get_docker_version actually emits, and the docker test must drive the producer, not a synthetic literal.

Not merging. I am not attempting a rewrite on top of five failed attempts in this subsystem without a maintainer decision on the dependency question, since packaging is a new runtime dependency.

Three findings, all reproduced before fixing:

1. The hand-rolled regex suffix (?:[-+.].*)? swallowed everything after the
   first separator and digits were then extracted from the whole string, so
   '2026-08-17-nightly' parsed as (2026,8,17) and compared NEWER than 3000.0.0.
   My own test called that input 'unreadable' and passed only because it
   compared against 2.0.0.

2. My docker guard matched a 'sha256:' prefix, but get_docker_version STRIPS
   that prefix and returns a bare 12-hex digest. The guard never fired against
   the real producer, silencing every docker update -- the exact failure the
   guard was written to prevent. I had tested it with invented literals.

3. Digit extraction ignored PEP 440 precedence: '1.0.0-rc1' -> '1.0.0' read as
   not-newer (hiding a real release) and the reverse read as newer.

Now uses packaging.version: PEP 440 ordering, InvalidVersion for anything that
is not a release. Compares the PUBLIC segment so build metadata is not treated
as a new release. Digest matching uses the bare hex form the producer emits,
with a test that drives the producer's truncation rather than a literal so the
two cannot drift apart again.
Self-review of the digest rule: 202612180000 is a plausible calendar/build
stamp and is 12 chars of [0-9a-f], so it matched _DIGEST_RE and was then never
compared against a dotted release -- silently dropping a real update. Require
at least one hex letter; a purely numeric string is a version and packaging
orders it.
…round 2)

Two blocking findings, both reproduced first:

1. Same digest, different representation compared as an update:
   n('abcdef123456','sha256:abcdef123456') was True -- a fabricated notice for
   an unchanged image. _digest_identity now canonicalises (drop prefix,
   truncate to the 12 chars the producer emits) and compares identities.

2. packaging reads npm/Cargo '1.0.0-1' as PEP 440 '1.0.0.post1', inverting
   SemVer precedence: it HID the real 1.0.0-1 -> 1.0.0 upgrade and fabricated
   the reverse. 79 of the manifest's 107 servers are npm. is_version_newer now
   takes an optional package_type and routes npm/cargo through SemVer 2.0.0
   precedence (build metadata ignored, release outranks prerelease, numeric
   identifiers rank below alphanumeric); pypi and unspecified keep PEP 440.

Five of six call sites now pass the ecosystem, taking it from
get_package_version's existing return value. The two catalog_search/cached-tuple
sites cannot: the memoized tuple does not record a package type. Left explicit
rather than silently defaulted.
Both remaining read sites -- catalog_search and _get_update_warning's warm-cache
branch -- fell back to PEP 440 because the memo stored only
(checked_at, current, latest). npm publishes SemVer, where 1.0.0-1 is a
PRERELEASE below 1.0.0; PEP 440 reads it as post-release 1.0.0.post1. Verified
the inversion was live: without a type, n('1.0.0-1','1.0.0') was False (hiding a
real upgrade) and the reverse True (fabricating one), reachable through
catalog_search after the direct paths were already fixed.

The memo is now a 4-tuple carrying pkg_type, written at all three writers
(_run_stale_index, _get_update_warning, update_server) and consumed at both
readers. grok: 'this is not an acceptable residual' -- correct, and it is now
closed rather than documented.
1. REGRESSION FROM THIS PR. refresher.py negates the comparator, and 'not
   newer' is not 'up to date' once the comparator fails closed. On main
   is_version_newer('unknown','2.0.0') was True, so the cache regenerated; with
   the fail-closed change it is False, so 'not(...)' became True and the stale
   'unknown' cache -- which this same function persists after a failed lookup --
   would be returned forever. Added is_version_orderable() and gated the call
   site on it. This is the hazard of making a predicate stricter: a negated
   caller silently inverts.

2. My hex-letter rule rejected valid all-numeric digests. get_package_version
   truncates SHA-256 to 12 hex chars, which can be all digits, so
   '987654321098' -> '123456789012' reported no update. Shape alone cannot
   disambiguate a digest from a calendar version, so package_type now decides:
   with docker, any hex shape is a digest; without it, a hex letter is required
   so a numeric version is still ordered.

3. _SEMVER_RE accepted strings SemVer forbids -- leading zeros in core and
   prerelease identifiers (rules 2 and 9) and empty identifiers (rule 10) -- and
   ORDERED them, so '1.0.0-01' fabricated an update to '1.0.0'. The pattern now
   validates per spec, and invalid input fails closed.
@ViperJuice

Copy link
Copy Markdown
Collaborator Author

Round 4: 1 AGREE / 1 DISAGREE / 1 no-verdict — three findings, all real, all fixed

Verified each against source before fixing. One was a regression this PR introduced, and it is the most instructive of the three.

1. My fail-closed change silently inverted a negated caller — REGRESSION

refresher.py:219 does not is_version_newer(existing_cache.version, version, pkg_type) and treats the result as "up to date". Those are not the same thing once the comparator fails closed.

Measured on main vs this branch:

main:        is_version_newer('unknown','2.0.0') -> True   =>  not(...) = False  => cache regenerates  (correct)
this branch: is_version_newer('unknown','2.0.0') -> False  =>  not(...) = True   => stale cache kept   (broken)

And refresher.py:233 persists the literal "unknown" after a failed version lookup — so that cache would be pinned forever, never regenerating even once the lookup started succeeding.

This is the hazard of making a predicate stricter: every negated call site silently flips meaning. Fixed by adding is_version_orderable() and gating the call site on it, so "not newer" is only trusted as "up to date" when the cached value is actually orderable.

2. My hex-letter rule rejected valid all-numeric digests

Last round I required a hex letter so a numeric calendar version (202612180000) would not be misread as a digest. But get_docker_version truncates SHA-256 to 12 hex chars, and that can be all digits — so 987654321098 → 123456789012, two genuinely different images, reported no update.

Shape alone cannot separate the two cases. package_type now decides: with docker, any hex shape is a digest; without it, a hex letter is still required so numeric versions keep being ordered. Both directions are tested.

3. _SEMVER_RE ordered strings SemVer forbids

It accepted leading zeros in core and prerelease identifiers (spec rules 2 and 9) and empty identifiers (rule 10), then ordered them — so 1.0.0-01 fabricated an upgrade to 1.0.0. These cannot be published to npm. The pattern now validates per spec and invalid input fails closed.

Verification

  • pytest -q → 2553 passed, 3 skipped, 0 failed (2536 baseline + 17)
  • ruff check, ruff format --check src/ tests/, mypy src — clean
  • RED proof: reverting only the two behavioral regexes (keeping the new function so collection still works — my first attempt at this proof failed on an ImportError and proved nothing) fails exactly the two new behavioral tests.

Standing residual

_semver_key is hand-written, which is what I argued against when replacing the digit-extraction parser. It is now pinned against the spec's own §11.4 precedence chain in both directions, including beta.2 < beta.11, plus the rule 2/9/10 validation above. If a maintainer would rather take a dependency on a real semver library than keep this, that is a reasonable call — but it is no longer unvalidated.

…uard

The changelog job failed with 'gh: Validation Failed (HTTP 422)' on its own
label lookup, during a window where the GitHub API was returning 503s. Under
'bash -e' that non-zero exit kills the step before the CHANGELOG check runs, so
the guard failed CLOSED on an unrelated API hiccup rather than on this PR's
content -- CHANGELOG.md is in the diff with an [Unreleased] entry. The Actions
rerun endpoint is itself 503ing, so this empty commit triggers fresh runs.

Filing the guard's fragility as a follow-up: the label lookup should tolerate a
transient API failure instead of failing the check.
The job grants 'pull-requests: read' but called /issues/<n>/labels, which needs
'issues: read'. GitHub returned 'Validation Failed (HTTP 422)', and under
'bash -e' that killed the step before the CHANGELOG check ran -- so the guard
blocked this PR on its own permissions, not on its content. Reproduced on two
independent runs, which is what ruled out my first read of it as a transient
API error.

Switches to /pulls/<n> .labels, covered by the granted scope, and tolerates a
lookup failure ('|| true') so an API hiccup means 'no label seen' rather than
'fail the check'.
@ViperJuice
ViperJuice merged commit 58b1a02 into main Aug 17, 2026
24 of 26 checks passed
ViperJuice added a commit that referenced this pull request Aug 19, 2026
* chore(plan): read installed versions from the npx/uv runner caches (#150)

Sixth attempt. Research killed the naive form first: every manifest server
launches via npx/uvx, which are EPHEMERAL RUNNERS -- 'npm ls @upstash/context7-mcp'
returns (empty), so shelling out to npm ls / uv pip show would have silenced
every notice, exactly the failure mode of attempt 4.

The runner CACHES are queryable, and that is the source all five prior attempts
lacked: each ~/.npm/_npx/<hash>/package.json names its package and node_modules
holds the resolved version (verified: @brightdata/mcp 2.11.1, @eslint/mcp
0.3.8), and uv's environments-v2 envs carry .dist-info metadata. Bound to
package identity by construction, unlike descriptions_cache.version (an upstream
snapshot) or serverInfo.version (an implementation version).

Cost measured: 0.36s for 114 npx entries, filesystem only, no subprocess.
Rests on #155, which made the comparator fail closed.

* chore(plan): record why matching on dependencies cannot hit a transitive

Sampled six npx cache entries: each declares exactly ONE dependency (the
invoked package) while node_modules holds the transitive closure -- 212
packages in one entry. So matching must be against 'dependencies'; matching
node_modules would resolve any transitive package. Checked before writing code
because it is the load-bearing assumption of the npm probe.

* chore(plan): record board rejection of the runner-cache plan (3/3 DISAGREE)

B1 (fatal): the plan kept descriptions_cache.version as a fallback while its own
acceptance criteria required silence in that case. That value is an upstream
snapshot (refresher.py:243), so identity agreement cannot make it an observed
installed version -- the sixth attempt would still emit inferred notices. My
proposed test specified 'no cache entry AND no orderable fallback', so it would
have passed while the real regression went uncovered: the same test-shape
failure as rounds 1-4 of PR #154.

B2: neither probe binds a CONFIGURED SERVER to the cache entry it runs. Verified
on this machine: the npx cache holds @consiliency/agent-board-mcp at 1.2.0,
1.2.1 AND 1.2.2 concurrently, so newest-by-mtime can pick an unrelated
invocation -- including update_server's own pkg@latest probe. uv is worse: an
env holds its full transitive closure (30 dist-infos observed) with no
single-declared-dependency marker, so a name scan matches transitives.

The caches do hold real installed versions -- that research stands. What they
lack is server->entry provenance. Recorded three candidate directions.

* chore(plan): record the installed version at spawn, plus a staleness guard (#150)

Seventh attempt. Maintainer decision after the runner-cache plan was rejected
3/3: record at spawn, plus a periodic guard so a long-running server does not
keep a stale baseline forever.

Spawn-time capture differs from all six prior attempts because provenance is by
CONSTRUCTION rather than inference: at manager.py:1365 pmcp calls
create_subprocess_exec with an argv it chose, so there is no scan, no
mtime heuristic, and no ambiguity about which cache entry is in play. Attempt 6
failed precisely there -- verified the npx cache holds one package at 1.2.0,
1.2.1 and 1.2.2 concurrently.

The maintainer's guard requirement is confirmed as a real gap: the existing
hourly sweep re-checks UPSTREAM latest, but a spawn-time value is captured only
at spawn, so a server left running for weeks keeps its baseline indefinitely.
The guard extends the existing _stale_indexer_loop rather than adding new
scheduling, and re-reads from disk without ever respawning a running server.

Carries the board's B1 finding forward: NO fallback to descriptions_cache.version
(an upstream snapshot), and the test for that case now includes an orderable
fallback, which the last plan's test omitted.

* chore(plan): record board rejection of the spawn-provenance plan

B1 (fatal): 'argv is authoritative provenance' is false. create_subprocess_exec
returns once NPX starts, before it has selected/fetched/launched the package,
and npm may resolve to a local, global or cache-backed copy -- only one branch
produces an _npx dir. Verified independently before the verdicts: a live
'npx -y @eslint/mcp' tree exposes NO _npx path in any descendant's cmdline, cwd
or fds. Knowing the argv tells pmcp what it ASKED FOR, not what npm CHOSE.

B2: the plan changed only the 'current' source. Both notice writers still take
'latest' from _get_server_config_for_update, which prefers the manifest and
ignores the .mcp.json override -- so B's installed version can still be compared
against A's upstream latest, which is the original #150 defect intact.

Seven attempts have now failed at the same joint: pmcp cannot observe which
package artifact is actually executing for a running server.

* chore(plan): ask the server its own version (#150, eighth attempt)

Maintainer decision after the spawn-provenance plan was rejected. Running the
package's own --version is the only method that observes the artifact that
actually runs, because the artifact answers for itself.

Measured against 12 real manifest servers before planning, not assumed:
  6/12 (50%) parsed a version
  @circleci/mcp-server-circleci IGNORED the flag and hit the 90s cap
  @eslint/mcp printed 'server is running' and EXITED 0 -- exit status is not a
    usability signal
  firecrawl-mcp emitted only npm noise containing koa-router@14.0.0, exactly
    what a permissive regex would grab as a version
Strict whole-line matching verified to extract 4.0.2 and 0.0.79 while rejecting
both bad cases. Median latency 5s, max 90s -> a 20s timeout and bounded
concurrency are load-bearing, and ~390s serial across 98 servers makes caching
required rather than an optimisation.

Carries both prior board findings forward: no fallback to
descriptions_cache.version (B1), and 'latest' must come from the same effective
config as the probe so an override cannot be compared against the manifest
package's latest (B2).

Open question recorded for the board: the probe STARTS the package, so a server
that ignores --version briefly runs with whatever side effects it has at
startup. If that is unacceptable the honest conclusion may be to remove the
feature rather than make it 50% correct.

* chore(plan): measure the probe's startup side effects -- 7 procs, 5 sockets

Probing @circleci/mcp-server-circleci --version (a server that ignores the flag)
spawns 7 processes and opens 5 network sockets, sustained until killed. That is
a real server starting and connecting outward, caused by an advisory update
check, for a server that returns no version anyway.

@eslint/mcp by contrast exits before 10s, so its cost is bounded.

Recorded because it is the strongest argument against this approach and the
maintainer asked for the board's view on exactly this point.

* chore(plan): board rejects the probe 3/3 and recommends removing the feature

Both readable legs answered the disqualifying question directly. grok:
'Automatic --version probes are disqualifying. Remove the automatic notices
rather than ship a 50%-correct scheduled spawn.' codex: 'It executes
third-party server code merely to produce an advisory.' Both rejected the
'probe only running servers' mitigation as relocating rather than removing the
hazard -- those servers are the ones most likely to hold live credentials.

Matches the measurement taken before the verdicts: one flag-ignoring server
spawned 7 processes and 5 sockets.

Separately, grok found and I verified a LIVE bug in shipped code:
_run_update_probe_command spawns without start_new_session and its 60s
wait_for abandons the child on timeout -- no kill, no process-group cleanup --
so today's gateway.update_server can orphan a whole process tree. Independent
of #150; to be filed separately.

* wip: reap the update probe's process tree on timeout (pre-test)

* test: prove the update probe reaps its process tree on timeout

* docs: CHANGELOG for the update-probe orphan fix

* fix: catch asyncio.TimeoutError so the probe is reaped on Python 3.10

asyncio.TimeoutError only became an alias of the builtin TimeoutError in 3.11.
This project supports 3.10, where 'except (TimeoutError, ...)' does not catch
what wait_for raises -- so the timeout escaped and _terminate_process_tree never
ran. The orphan fix silently did nothing on the oldest supported version.

CI caught it: test (3.10) failed while 3.11 and 3.12 passed, and the runner log
showed 'Terminate orphan process: pid (3919) (python)' -- my own regression test
leaking the processes it spawns. Reproduced locally on 3.10, then verified all
three versions pass with the fix.

* test: run the orphan proof in a child interpreter

The test must spawn real process trees -- a mock cannot demonstrate an orphan.
But doing that inside the pytest process perturbs state neighbouring tests
measure: tests/runtime/test_downstream_remote.py asserts on THIS process's open
socket count and on the process-global sse_starlette.AppStatus latch. My test
knocked over two different tests on CI's 3.10 lane while passing locally.

Moves the scenario into a subprocess, so the real-process proof survives without
touching the suite's shared state. Stdlib subprocess -- no test-runner plugin
and no new dependency for one test.

Verified: reverting the fix makes it report 'ORPHANED' (RED); with the fix
'REAPED'; and test_tools.py + test_downstream_remote.py together now pass 188/188
on 3.10, the combination that was failing.
@ViperJuice ViperJuice mentioned this pull request Aug 20, 2026
ViperJuice added a commit that referenced this pull request Aug 21, 2026
1. _semver_key replaced by the semver library (new dep, zero transitive deps).
   Verified it rejects every case the hand-written regex was built to reject
   (leading zeros, empty identifiers, non-SemVer) and orders beta.2 < beta.11.

2. is_version_newer's docstring now warns that a bare `not is_version_newer(...)`
   is unsafe, and an AST-based test enforces it repo-wide -- proven to fire by
   removing the real guard in refresher.py.

3. All-numeric digest disambiguation. Fail-closed was tried and REVERTED: it
   broke CalVer, which #155 deliberately supports and pins with an existing
   test -- trading a live capability for a latent bug. The partner value
   disambiguates instead. Also fixed a real bug found on the way: the digest
   pattern demanded a hex letter even when the `sha256:` prefix was present,
   rejecting all-numeric prefixed digests.

4. The changelog CI guard falls back to the frozen event payload rather than
   reading a failed lookup as 'no label'. Payload passed via env, never
   interpolated into the script body. Fallback logic tested locally across
   eight cases including malformed and null JSON.
ViperJuice added a commit that referenced this pull request Aug 22, 2026
* fix: close the four #155 follow-ups (#156)

1. _semver_key replaced by the semver library (new dep, zero transitive deps).
   Verified it rejects every case the hand-written regex was built to reject
   (leading zeros, empty identifiers, non-SemVer) and orders beta.2 < beta.11.

2. is_version_newer's docstring now warns that a bare `not is_version_newer(...)`
   is unsafe, and an AST-based test enforces it repo-wide -- proven to fire by
   removing the real guard in refresher.py.

3. All-numeric digest disambiguation. Fail-closed was tried and REVERTED: it
   broke CalVer, which #155 deliberately supports and pins with an existing
   test -- trading a live capability for a latent bug. The partner value
   disambiguates instead. Also fixed a real bug found on the way: the digest
   pattern demanded a hex letter even when the `sha256:` prefix was present,
   rejecting all-numeric prefixed digests.

4. The changelog CI guard falls back to the frozen event payload rather than
   reading a failed lookup as 'no label'. Payload passed via env, never
   interpolated into the script body. Fallback logic tested locally across
   eight cases including malformed and null JSON.

* fix: clear the sse_starlette AppStatus latch on entry, not only exit (#158)

Root cause of the intermittent test_ec_p2_7_reconnect_does_not_leak_transports
failure, confirmed empirically rather than inferred:

    latched=False: connect OK
    latched=True:  FAILED -> Exception: SSE stream ended without a response

which is the exact error CI reported.

AppStatus.should_exit is a process-global class attribute latched True by
uvicorn's shutdown handler and never reset. run_fake_remote cleared it in its
finally block, which protects against its OWN shutdown but not against a server
stopped elsewhere in the interpreter -- tests/mcp2x/test_listen_over_http.py and
tests/test_http_dos.py both stop uvicorn servers, and tests/mcp2x sorts
immediately before tests/runtime. That is why CI failed at ~3% of the suite and
why it never reproduced in isolation.

Clearing on entry makes the server immune to whatever ran before it. Added a
test that latches the flag deliberately and asserts a connection still works;
proven RED without the entry reset.

#158 suggested instrumenting the assertion to learn which descriptors were open.
That was the right next step for a socket-count failure, but the failures were
the SSE stream dying, not the count.

* fix: address all three board findings on #156

1. Partner promotion REVERTED. Promoting an all-numeric value to a digest
   because its partner is one resolves the ambiguity by guessing, and the guess
   fabricates: a genuine CalVer paired with a digest reported an update that
   never happened. That is precisely what is_version_newer's fail-closed
   contract exists to prevent. Mixed pairs stay incomparable; a new test pins
   that every caller passes package_type, which is what actually resolves it.
   The separate sha256:-prefix fix is retained -- that one was unambiguous.

2. The AST negation test checked only CO-OCCURRENCE. It now requires the guard
   to be a SIBLING operand of the and-chain, positioned BEFORE the negation, and
   about the SAME value. My first strengthening still accepted the reviewer's
   bypass, because walking into each operand finds a guard nested in an `or`.
   Verified against four bypasses: or-nesting, wrong subject, no guard, and
   guard-after-negation. All four now fail; real code passes.

3. The CI label fallback branched on EMPTINESS, conflating "lookup failed" with
   "lookup succeeded, zero labels". A removed skip-changelog label would have
   been resurrected from the frozen payload and silently bypassed the guard --
   worse than the false block the fallback was added to prevent. It now branches
   on exit status. Tested across five cases including label-removed.

* fix: make the negation guard model control flow, not syntax

Third attempt at this check, and both reviewers found the same remaining hole:

    (ready or is_version_orderable(x)) and not is_version_newer(x, y)

passed, because the guard was searched for RECURSIVELY inside each preceding
and-operand -- so a guard nested in an `or` counted, even though `ready` being
true skips it entirely. The previous version also REJECTED a genuinely safe
guard in an enclosing `if`, confirming it modelled syntax rather than effect.

Now computes the conditions that must actually hold where the negation is
evaluated: conjuncts of preceding operands in an enclosing `and` chain, plus
conjuncts of an enclosing `if` test when the negation is in its body (not its
orelse). An `or` contributes only itself, never its branches.

Verified against five bypasses (or-nested guard in a preceding operand,
`not newer or guard`, wrong subject, no guard, guard after the negation) and
four legitimate shapes (bool()-wrapped, parenthesised negation, enclosing `if`,
`if ready and guard`). Also rejects a guard that only holds in the `if`'s else
branch.

Plus the stale prose the reviewer flagged: the digest test's class docstring
still described the reverted partner-promotion design, the workflow comments
still described the replaced empty-check fallback, the CHANGELOG had picked up
a duplicate '### Fixed' heading, and its CI bullet omitted that a SUCCESSFUL
empty lookup stays authoritative.

* test: accept a walrus-assigned guard, document the early-exit limitation

Found while pre-attacking the review questions: (ok := is_version_orderable(v))
is truthy exactly when the guard is, so it should count -- NamedExpr is now
stripped alongside bool() and double negation.

An early-exit guard (if not is_version_orderable(v): return False) is genuinely
safe but still trips this check. Documented in the test rather than left for
someone to rediscover, with instructions to restructure or extend the analysis
rather than delete the assertion.

* fix: guard BOTH sides of the version comparison (#156 item 2, for real)

Board review proved the item-2 defect was still live after three rounds of
fixing the lint that was supposed to police it. The guard checked only the
CACHED side:

    is_version_orderable('1.0.0')             = True
    is_version_newer('1.0.0','nightly','npm') = False   (fails closed)
    guard and not newer                       = True    -> "up to date"

so an unreadable FETCHED version pinned the stale cache exactly as the original
bug did. Both sides are now checked, and both checks carry pkg_type -- the type
is what makes an all-numeric docker digest orderable at all.

Added a behavioural regression test asserting the observable (refresh_server
calls get_package_version a second time on the refresh path, so a short-circuit
is detectable), proven RED against the one-sided guard. The lint now requires
both operands guarded and reports which one is unguarded; also proven RED.

The lint remains unsound by construction -- it matches syntax, so it accepts a
guarded value that is reassigned before use, or a guard whose scope ended before
a closure runs. Documented in the test rather than left implicit, and filed as
#164: replacing the fail-closed boolean with a tri-state result
makes the hazard unrepresentable and this lint unnecessary.

* fix: comparability is a property of the PAIR, not of each value (#156 item 2)

Fifth board finding, and the deepest. Two unary orderability guards do not
prove a pair can be ordered:

    is_version_orderable('1.0.0', 'docker')        = True
    is_version_orderable('abcdef123456','docker')  = True
    is_version_newer('1.0.0','abcdef123456','docker') = False   (mixed, fails closed)
    -> guard passes, negation reports "up to date"

A version and a digest are each orderable alone but mutually incomparable, so
the short-circuit returned the stale npm cache for a server now configured for
docker. Reachable because refresh_all reuses a cache entry by server NAME and
never checks the package still matches.

Adds are_versions_comparable(current, latest, package_type), which mirrors
is_version_newer's own branching, and switches the refresher to it. The lint now
requires that pair predicate over the same two operands rather than unary
guards; both it and a new behavioural test are proven RED against the
two-unary-guard version.

is_version_orderable is no longer used in src/. Kept for genuinely unary
questions, but its docstring -- and is_version_newer's, which still advised
gating on it -- now say plainly that it is NOT sufficient to guard the negation.

The pair/unary drift test initially failed on ('abcdef123456',
'sha256:abcdef123456'): the same image in two spellings is comparable and
EQUAL, so neither direction is newer. My premise was wrong, not the code. It
now asserts the invariant that actually matters -- an incomparable pair is
never reported newer in either direction.

* test: enforce package_type in the guard, and test the dangerous drift direction

Board findings 6 and 7, both correct.

1. The lint compared only the first two operands, so a guard that DROPPED
   package_type passed while the comparator beside it used one. The type
   decides classification -- are_versions_comparable("202612180000","1.0.0") is
   True while the same pair as docker is incomparable -- so that guard would
   have recreated the unsafe short-circuit. All three operands must now match.

2. The drift test SKIPPED every pair the predicate accepts, so it could only
   catch "predicate rejects something orderable" (which suppresses updates). The
   dangerous direction is the opposite: predicate accepts a pair the comparator
   cannot order, which is exactly what lets `not is_version_newer(...)` read
   "up to date". Now tested over the same 28-value x 7-type corpus: an accepted
   pair must either order in one direction or be canonically the SAME value.

Writing (2) exposed two bugs in my own canonical-identity helper before it
passed, both of which reported drift that was not there:
  - comparing rendered strings, so 1.0 vs 1.0.0 (the same PEP 440 release)
    looked different;
  - then comparing full Version objects, so 1.0.0 vs 1.0.0+b looked different,
    when is_version_newer deliberately compares .public because build metadata
    is not a new release.

Proven RED by injecting drift into are_versions_comparable: the test names
('1.0.0','abcdef123456',None) as comparable-but-unorderable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant